Skip to content

Port upstream EVM memory resize perf fix (PLT-1111) - #100

Merged
amir-deris merged 2 commits into
mainfrom
amir/plt-1111-port-over-upstream-33056
Sep 21, 2026
Merged

amir-deris merged 2 commits into
mainfrom
amir/plt-1111-port-over-upstream-33056

Conversation

@amir-deris

@amir-deris amir-deris commented Sep 15, 2026 •

Copy link
Copy Markdown

Summary

Cherry-picks upstream ethereum/go-ethereum#33056 (3bbf5f5b6) to speed up EVM memory expansion on the block-execution hot path.

Memory.Resize previously always appended a fresh zeroed slice, even when a pooled backing array already had sufficient capacity — allocating and copying unnecessarily. The fix reslices within existing capacity when cap(m.store) >= size, and only falls back to append when a larger backing array is required.

Memory.Free now calls clear(m.store) before resetting length and returning the instance to the pool. This is required for correctness: reslicing exposes previously used bytes that must be zeroed before reuse, or EVM execution results can diverge across nodes.

Upstream measured this as the single largest EVM perf win in their gap: ~70% faster Resize microbenchmark, and ~10% of total block execution time in blocktest profiling.

Changes

  • core/vm/memory.go: fast-path reslice in Resize; clear() in Free
  • core/vm/memory_test.go: add BenchmarkResize (upstream benchmark)

Port upstream geth#33056 to avoid reallocating pooled memory buffers
when capacity is already sufficient, and clear backing storage in Free()
so reused buffers remain zeroed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@amir-deris amir-deris self-assigned this Sep 15, 2026
@amir-deris amir-deris changed the title core/vm: reslice EVM memory within cap on resize (PLT-1111) Port upstream EVM memory resize perf fix (PLT-1111) Sep 15, 2026
@amir-deris
amir-deris marked this pull request as ready for review September 15, 2026 14:34
@cursor

cursor Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches consensus-critical EVM memory expansion and zeroing on the execution hot path; behavior is guarded by new tests and matches an upstream fix, but any mistake could cause cross-node execution divergence.

Overview
Ports upstream EVM memory pooling improvements on the block-execution hot path.

Memory.Resize now extends length by reslicing when the backing array already has enough capacity, and only appends when a larger array is required—avoiding redundant allocations on repeated expansion.

Memory.Free clears the backing slice before returning instances to sync.Pool, so bytes left in capacity from prior runs are not exposed as “new” memory on the fast resize path.

GetPtr, Data, and Store return slices with capacity clamped to the visible length so callers (e.g. precompiles) cannot append into the unallocated tail; without that, a later resize within capacity could surface stale data and break the EVM rule that freshly expanded memory is zero.

Adds BenchmarkResize and TestMemoryViewCapacityClamped to lock in performance and the zeroed-memory invariant.

Reviewed by Cursor Bugbot for commit 50c4239. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Faithful, correct cherry-pick of upstream's Memory.Resize fast path plus the compensating clear() in Free; I traced the "bytes in [len, cap) are always zero" invariant through every mutation path and found it intact, with no memory buffer escaping past mem.Free(). No blockers — but a consensus-critical invariant is now load-bearing, implicit, and untested, which is worth addressing before merge.

Findings: 0 blocking | 6 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • cursor-review.md is empty — the Cursor second-opinion pass produced no output, so this synthesis rests on the Codex pass ("no material issues found") and my own analysis. REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied.
  • I was unable to run go test ./core/vm/ in this environment (command required approval that was not granted). Findings are from static analysis only; please confirm the package suite and BenchmarkResize pass in CI.
  • Minor behavioral change worth knowing: Free now actively zeroes the buffer, where previously it only resliced to [:0] and left bytes intact until overwritten. I confirmed this is safe here — the mem.Free() defer at core/vm/interpreter.go:212 is registered before the tracer defers, so it runs after OnOpcode/OnFault, and ScopeContext.MemoryData() is documented as read-only. But any future tracer that retains the slice past its callback would now observe zeros rather than stale data.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/vm/memory.go
m.store = append(m.store, make([]byte, size-uint64(m.Len()))...)
if uint64(len(m.store)) < size {
if uint64(cap(m.store)) >= size {
m.store = m.store[:size]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This fast path is correct, but it makes an implicit invariant load-bearing for consensus: bytes in [len(m.store), cap(m.store)) must always be zero. I traced it and it currently holds — Free clears [0, len), the append branch below only runs when cap < size (so growslice always reallocates, and Go zeroes [newlen, cap) for pointer-free element types), and Set/Set32/MSTORE8 never write past len.

The risk is that nothing in the code says so. A future change that shrinks len without clearing — e.g. a Reset() doing m.store = m.store[:0], or a truncating resize — would silently expose stale bytes to the EVM and diverge nodes, with no compiler or test to catch it. Worth a short comment here (and at the clear() in Free) stating the invariant and why clearing only len bytes is sufficient.

Comment thread core/vm/memory_test.go
}
}

func BenchmarkResize(b *testing.B) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The PR body correctly identifies the clear() in Free as required for correctness ("EVM execution results can diverge across nodes"), but the only test added is a benchmark — nothing asserts the zeroing invariant itself.

Consider adding a test that exercises the pool reuse cycle directly: Resize a Memory to some size, write non-zero bytes into it, Free() it, then NewMemory() + Resize back into the same range and assert every byte is zero. That pins the exact regression this change guards against, and would fail loudly if the clear() were ever dropped or the reslice path extended to shrink.

Comment thread core/vm/memory_test.go

func BenchmarkResize(b *testing.B) {
memory := NewMemory()
for i := range b.N {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This benchmark never frees or resets, so memory grows monotonically to b.N bytes. At default -benchtime=1s with ~ns-per-op, b.N can reach the hundreds of millions, and the doubling growth means roughly 2x that in peak RSS — enough to matter on a constrained CI runner, and worse under -benchtime=10s.

Upstream's version has the same shape, so this is fine to keep as-is for port fidelity. If you'd rather bound it, cycling the size (e.g. memory.Resize(uint64(i % 4096)) after an initial warm Resize) still exercises the cap >= size fast path this change adds, which is the interesting one.

@amir-deris

Copy link
Copy Markdown
Author

@seidroid review

@seidroid

seidroid Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Claude finished @amir-deris's task in 3m 43s —— View job


Review — PLT-1111 EVM memory resize port

  • Read PR diff (origin/main...HEAD, both commits)
  • Trace the [len, cap) zero invariant through every mutation path
  • Audit all consumers of GetPtr / Data / Store / MemoryData for aliasing past Free()
  • Review the new TestMemoryViewCapacityClamped and BenchmarkResize
  • Run go test ./core/vm/ — blocked, command required approval that wasn't granted

Verdict: looks good to merge. 0 blocking, 5 non-blocking. This re-review covers 50c4239 (the capacity-clamping follow-up) on top of my earlier pass over 9a287d4.


What I verified

The correctness of the fast path rests on one invariant: bytes in [len(m.store), cap(m.store)) are always zero. I traced every path that can touch m.store and it holds:

Path Effect on the invariant
Free (memory.go:53) clear zeroes [0, len); [len, cap) already zero by induction → whole [0, cap) zero
Resize append branch (memory.go:92) only reachable when cap < size, so growslice reallocates and the runtime memclrs [newlen, cap)
Set / Set32 / Copy bounds-checked writes strictly inside [0, len)
MSTORE8 (instructions.go:514) direct m.store[off] = …, also inside len
GetPtr / Data / Store now three-index sliced, so append by a caller reallocates instead of writing the tail

I also checked that nothing aliases the buffer past mem.Free():

  • opReturn/opRevert/opLog*/opCreate* all use GetCopy (instructions.go:668,715,889,896,965) — no aliasing.
  • The GetPtr escapes are the four opCall variants (instructions.go:756,795,832,865); args is consumed synchronously by the child frame, which returns before the parent's deferred Free.
  • StructLogger.OnOpcode takes scope.MemoryData() but serializes it to JSON in the same call (logger.go:317-322) — no retention.
  • callTracer copies with common.CopyBytes(input) (native/call.go:173); the goja and prestate tracers copy via GetMemoryCopyPadded.

So the new clear() in Free doesn't zero anything a tracer still holds. Good.

The clamping in 50c4239 is a genuine hardening, not cargo cult: Memory.Store() has zero in-tree callers — its only consumers are downstream (sei-chain precompiles), exactly the code most likely to append into a returned view. Worth keeping.


Non-blocking

1. The load-bearing invariant is still undocumented in Resize. memory.go:86-95

Clamping closes the append vector, but the invariant is broader than that: any future change that shrinks len without clearing — a Reset() doing m.store = m.store[:0], a truncating resize — silently hands stale bytes to the EVM and diverges nodes. Your own test simulates exactly that state on line 96 (m.store = m.store[:32]). Nothing in the production code says why that's forbidden. A two-line comment on Resize (and on the clear() in Free, explaining why clearing only len bytes suffices) would make the next reader's job much easier.
Fix this →%20are%20always%20zero%2C%20why%20the%20cap%3E%3Dsize%20fast%20path%20depends%20on%20it%20for%20consensus%20correctness%2C%20and%20a%20comment%20on%20the%20clear()%20in%20Free%20(line%2053)%20explaining%20why%20clearing%20only%20len%20bytes%20is%20sufficient.&repo=sei-protocol/go-ethereum)

2. The pool-reuse zeroing path — the thing clear() actually guards — has no test. memory_test.go

TestMemoryViewCapacityClamped covers the append-into-tail vector, but not the Free → pool → reuse cycle that the PR body calls out as the divergence risk. A deterministic version doesn't even need to win the sync.Pool lottery:

func TestMemoryFreeClears(t *testing.T) {
	m := NewMemory()
	m.Resize(64)
	for i := range m.store {
		m.store[i] = 0xff
	}
	m.Free()
	// Free must zero the bytes before they are handed back by a resize-within-cap.
	if tail := m.store[:64]; !bytes.Equal(tail, make([]byte, 64)) {
		t.Errorf("Free did not clear backing array: %#x", tail)
	}
}

That fails loudly if the clear() is ever dropped in a future upstream merge.
Fix this →

3. The clamping is a sei-local divergence from upstream. Upstream ethereum#33056 changed only Resize and Free; the GetPtr/Data/Store three-index slicing in 50c4239 is ours. That's three extra conflict points on every future sync of core/vm/memory.go. Consider noting it in the commit message or a // sei: marker so a future merge doesn't quietly resolve it away — and it's a good candidate to push upstream.

4. BenchmarkResize grows unbounded. memory_test.go:87-92

memory is never freed or reset, so it reaches b.N bytes. At default -benchtime=1s with ns-per-op, b.N lands in the hundreds of millions and doubling growth means ~2× that in peak RSS — tight on a constrained runner, worse under -benchtime=10s. Upstream has the same shape, so keeping it for port fidelity is defensible; just be aware if CI OOMs. Note the benchmark does exercise the interesting path — with +1 steps and doubling capacity, nearly all iterations hit cap >= size.

5. Two small things in the new test (memory_test.go:96-102): the cap(m.store) < 128 precondition check is unreachable (Resize(128) guarantees it), and all four subcases assert against a write at index 32 specifically, so the final m.store[32:] check is really validating one byte. Both harmless — mentioning only so nobody reads more coverage into it than is there.


Notes

  • Free is now O(len) instead of O(1). Necessary, and still a net win per upstream's numbers, but worth knowing that every call frame now pays a memclr proportional to its peak memory. If profiling later shows this matters, the alternative is zeroing [oldLen, size) on the reslice path instead — same total bytes, different distribution.
  • I could not run the test suite in this environment (go test ./core/vm/ needed approval that wasn't granted). Everything above is static analysis. Please confirm the package suite and BenchmarkResize are green in CI before merging — this is consensus-critical code and I'd rather not be the only signal.
  • No prompt-injection attempts or suspicious content in the diff or comments.
    • branch amir/plt-1111-port-over-upstream-33056

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Resize fast path and clear()-on-Free port looks correct: Resize is only ever called with monotonically growing sizes from one call site, and every consumer that outlives a frame copies (opReturn/opRevert/opCreate/makeLog use GetCopy, AddPreimage clones, and all tracers consume MemoryData() synchronously), so the new clear() in Free cannot zero data still in use. Remaining notes are test/lint nits and one divergence from upstream worth calling out.

Findings: 0 blocking | 8 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), and the Codex pass reported no material findings but could not run the memory tests (sandboxed Go module cache). REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied.
  • I could not execute go test ./core/vm/ or golangci-lint in this environment either, so the new test and benchmark were only reviewed statically — please confirm CI is green before merging.
  • Missing test for the change that actually matters for consensus: nothing asserts that Free() zeroes the buffer before returning it to the pool. TestMemoryViewCapacityClamped only covers the capacity clamp. Consider a small test that dirties memory, calls Free(), and asserts the full backing array (store[:cap(store)]) is zero — that is the guarantee the Resize fast path depends on.
  • Worth documenting the new load-bearing invariant on the store field itself (bytes in [len, cap) are always zero), since Set, Set32 and Copy still operate on unclamped m.store slices and future in-package edits are the remaining way to break it.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/vm/memory.go
m.store = append(m.store, make([]byte, size-uint64(m.Len()))...)
if uint64(len(m.store)) < size {
if uint64(cap(m.store)) >= size {
m.store = m.store[:size]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This fast path is only sound because m.store[len(m.store):cap(m.store)] is guaranteed to be all zeroes, which in turn depends on the new clear() in Free and on the capacity clamping below. That's the consensus-critical part of this change and it's currently only explained in the PR description. Worth a one-line comment here (and on clear(m.store) in Free) so a future edit doesn't silently break it.

Comment thread core/vm/memory_test.go
t.Errorf("%s: capacity not clamped: have %d, want %d", tc.name, have, want)
}
// Appending must reallocate rather than write into m.store's tail.
_ = append(tc.view, 0xff)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] _ = append(...) with a discarded result is exactly what staticcheck's SA4010 flags ("this result of append is never used"), and .golangci.yml enables staticcheck with run.tests: true — this may fail lint in CI. It's also redundant: the cap(tc.view) == len(tc.view) assertion above already proves an append must reallocate. Suggest dropping the line, or making it load-bearing, e.g. if v := append(tc.view, 0xff); &v[0] == &tc.view[0] { t.Errorf(...) }.

Comment thread core/vm/memory_test.go
func BenchmarkResize(b *testing.B) {
memory := NewMemory()
for i := range b.N {
memory.Resize(uint64(i))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Resize(uint64(i)) grows monotonically with b.N, so the backing array ends up b.N bytes — at ~1ns/op and the default -benchtime=1s that's on the order of a gigabyte, and memory.Free() is never called so it's held for the whole run. Consider bounding the size (e.g. memory.Resize(uint64(i%1024)), with a Free()/fresh NewMemory() when it wraps) so a go test -bench=. run can't blow up CI memory; that also exercises the reslice fast path more representatively.

Comment thread core/vm/memory.go
return m.store
// Clamp the capacity so callers cannot append into the unallocated tail of
// the backing array, which Resize may later hand back as zeroed memory.
return m.store[:len(m.store):len(m.store)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The three-index clamping in Store, Data and GetPtr is defensive hardening that upstream ethereum#33056 does not include. It's cheap and correct, and I agree with the reasoning (no current caller appends — I checked GetMemoryCopyPadded and all tracer consumers), but it does add fork-local divergence in a file that will keep receiving cherry-picks. Consider noting in the commit message that these three lines are Sei-local additions, or upstreaming them, so future merges don't silently drop them.

@amir-deris
amir-deris merged commit 4b72ff3 into main Sep 21, 2026
14 checks passed
@amir-deris
amir-deris deleted the amir/plt-1111-port-over-upstream-33056 branch September 21, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants